package de.mmis.utilities.consoleWrapper;

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.CountDownLatch;

import javax.swing.*;

import org.apache.log4j.Logger;

/**
 * <p>
 * The ConsoleWrapper is used to create a window around a Java console
 * application. It reassigns new Streams to stdin/out/err. There are two
 * different operation modes:
 * </p>
 * <b>Transparent mode</b>
 * <p>
 * The application does not need to know about the ConsoleWrapper, you just need
 * to specify the JAR or the class containing the main-Method of the application
 * on the command line. It makes the main-Method of this class run first, which
 * will create the window and redirect the streams. It can also display
 * additional {@link JPanel}s in tabs, when they are specified in the Manifest
 * of the JAR. After everything is set up, it will run the original main-Method.
 * <br/>
 * The main-Method may call the static addPanel() method if it wants to add a
 * Panel. If the main-Method is not called by the consoleWraper (and hence no
 * tab can be added), this method returns false.
 * </p>
 * <b>Explicit mode</b>
 * <p>
 * The application has to call <code>createInstance()</code> explicitly, but can
 * also use the window of the ConsoleWrapper to display additional
 * {@link JPanel}s in tabs.
 * </p>
 * 
 * @author Martin Nyolt
 * 
 */
public class ConsoleWrapper extends JFrame {

	private final static Logger LOGGER = Logger.getLogger(ConsoleWrapper.class);

	private static final long serialVersionUID = -6936473493202278846L;

	/**
	 * InputStream extending the normal {@link InputStream}: the
	 * {@link #read(byte[], int, int)} method respects the result of
	 * {@link #available()}.
	 * 
	 * @author Martin Nyolt
	 * 
	 */
	private static abstract class ImprovedInputStream extends InputStream {

		/**
		 * copied from {@link InputStream#read(byte[], int, int)}, but added
		 * test if {@link #available()}
		 */
		@Override
		public int read(byte[] b, int off, int len) throws IOException {
			/*
			 * Simply copied from the original InputStream class, only the test
			 * if more bytes are available has beed added
			 */
			if (b == null) {
				throw new NullPointerException();
			} else if (off < 0 || len < 0 || len > b.length - off) {
				throw new IndexOutOfBoundsException();
			} else if (len == 0) {
				return 0;
			}

			int c = read();
			if (c == -1) {
				return -1;
			}
			b[off] = (byte) c;

			int i = 1;
			try {
				for (; i < len && available() > 0; i++) {
					c = read();
					if (c == -1) {
						break;
					}
					b[off + i] = (byte) c;
				}
			} catch (IOException ee) {
				// treat as end of stream
			}
			return i;
		}

	}

	private class MyPrintStream extends PrintStream {

		private final boolean stdErr;

		public MyPrintStream(boolean stdErr) {
			super(new OutputStream() {
				@Override
				public void write(int b) throws IOException {
					// do nothing
				}
			});

			this.stdErr = stdErr;
		}

		@Override
		public void write(final byte[] buf, final int off, final int len) {
			final String s = new String(buf, off, len);
			ConsoleWrapper.this.write(Thread.currentThread(), s, stdErr);
		}
	}

	private HashMap<Component, String> tab_titles = new HashMap<Component, String>();

	private JMenuBar menu;
	private JMenu menu_options;
	private JCheckBoxMenuItem menu_options_autoScroll;
	private JMenuItem menu_options_lineCount;
	private JMenuItem menu_options_undock;

	private JMenu menu_logging;
	private JMenuItem menu_logging_setLevel;

	private ThreadConsoleEditorPane output;
	private JTextField input;
	private JPanel consolePanel;

	private JTabbedPane tabs;

	/** The array of commands the user entered, that have not been processed */
	private LinkedList<String> commands;

	private final InputStream inStream;
	private final PrintStream outStream;
	private final PrintStream errStream;

	private boolean localEcho = true;
	private int capacity = 100 * 1024;
	private boolean autoScroll = true;
	private FileWriter logWriter = null;

	private CountDownLatch streamsReassigned = new CountDownLatch(1);

	private static ConsoleWrapper instance;

	/**
	 * if true, text entered will be displayed in a blue font. Default: true
	 */
	public void setLocalEcho(boolean value) {
		localEcho = value;
	}

	/**
	 * Limit the maximum number of character stored to this value. Default: -1
	 * (no limit)
	 */
	public void setCapacity(int value) {
		capacity = value;
		output.setCapacity(value);
		menu_options_lineCount.setText("capacity (" + value / 1024 + "k)");
	}

	public void setAutoScroll(boolean scroll) {
		autoScroll = scroll;
		menu_options_autoScroll.setSelected(scroll);
		output.setAutoScroll(autoScroll);
	}

	/**
	 * Will write output to a file called "yyyyMMdd_HHmmssSSS [logFile]".
	 * <b>null</b> will disable logging.
	 * 
	 * @param logFile
	 * @throws IOException
	 *             If the file could not be created.
	 */
	public void setLogPrefix(String logFile) throws IOException {
		if (logFile == null) {
			if (logWriter != null) {
				logWriter.close();
				logWriter = null;
			}
			return;
		}

		String filename = logFile + " "
				+ new SimpleDateFormat("yyyyMMdd_HHmmssSSS").format(new Date());
		File file = new File(filename);
		System.out.println("Logging to file " + file.getAbsolutePath());
		logWriter = new FileWriter(file);
	}

	/**
	 * @return the instance of the {@link ConsoleWrapper}, or <b>null</b> if it
	 *         has not been created.
	 */
	public static ConsoleWrapper getInstance() {
		return instance;
	}

	/**
	 * Adds the tab with the given title, if the ConsoleWrapper is running. You
	 * can only use this method if the panel-List passed to createInstance() was
	 * not null.
	 * 
	 * @return false, if no ConsoleWrapper is running, true otherwise
	 */
	public static boolean addComponent(String title, Component panel) {
		if (instance == null)
			return false;

		instance.addTab(title, panel, true);
		return true;
	}

	/**
	 * Creates and shows the {@link ConsoleWrapper} window and redirects
	 * stdin/out/err. If the window already exists, just returns it and does
	 * nothing.<br/>
	 * <i>Note</i>: you maybe want to call <code>setTitle()</code> to change the
	 * title to your application name.
	 * 
	 * @param exitOnClose
	 *            If the VM should exit when the user clicks the close button.
	 *            If false, disposes the window.
	 * @param panels
	 *            {@link JPanel}s that should be shown additionally to the
	 *            Console in seperate tabs. The key determines the label of the
	 *            tab. May be <b>null</b>.
	 * @return the {@link ConsoleWrapper} instance
	 */
	public static ConsoleWrapper createInstance(final boolean exitOnClose,
			final Map<String, JPanel> panels) {
		if (instance != null)
			return instance;

		try {
			SwingUtilities.invokeAndWait(new Runnable() {
				@Override
				public void run() {
					instance = new ConsoleWrapper(exitOnClose, panels);
					instance.setVisible(true);
				}
			});
		} catch (InterruptedException e) {
			// ignore
		} catch (InvocationTargetException e) {
			LOGGER.error(e);
		}

		while (instance.streamsReassigned.getCount() > 0)
			try {
				instance.streamsReassigned.await();
			} catch (InterruptedException e) {
				// ignore
			}

		return instance;
	}

	private ConsoleWrapper(boolean exitOnClose, Map<String, JPanel> panels) {
		if (exitOnClose)
			setDefaultCloseOperation(EXIT_ON_CLOSE);
		else
			setDefaultCloseOperation(DISPOSE_ON_CLOSE);

		commands = new LinkedList<String>();

		// create streams
		inStream = new ImprovedInputStream() {
			@Override
			public int read() throws IOException {
				return getNextInput();
			}

			@Override
			public int available() throws IOException {
				return getAvailableInput();
			}
		};

		outStream = new MyPrintStream(false);
		errStream = new MyPrintStream(true);

		if (panels == null)
			panels = new HashMap<String, JPanel>();

		initGUI(panels);

		// Reassign stdin/out/err
		System.setIn(inStream);
		System.setOut(outStream);
		System.setErr(errStream);

		streamsReassigned.countDown();
	}

	private void initGUI(Map<String, JPanel> panels) {
		setTitle("Console Wrapper");

		{
			menu = new JMenuBar();
			setJMenuBar(menu);

			menu_options = new JMenu("Options");
			menu.add(menu_options);

			menu_options_autoScroll = new JCheckBoxMenuItem("auto scroll");
			menu_options_autoScroll.setSelected(autoScroll);
			menu_options_autoScroll.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					setAutoScroll(menu_options_autoScroll.isSelected());
				}
			});
			menu_options.add(menu_options_autoScroll);

			menu_options_lineCount = new JMenuItem("capacity (" + capacity
					/ 1024 + " k)");
			menu_options_lineCount.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					String input = JOptionPane.showInputDialog(
							"Limit number of characters to (kBytes): ", ""
									+ capacity / 1024);
					if (input == null)
						return;

					try {
						int count = Integer.parseInt(input);
						setCapacity(count * 1024);
					} catch (NumberFormatException _) {
						JOptionPane.showMessageDialog(ConsoleWrapper.this,
								input + " is not a valid number", "Error",
								JOptionPane.ERROR_MESSAGE);
					}

				}
			});
			menu_options.add(menu_options_lineCount);

			menu_options_undock = new JMenuItem("Undock current tab");
			menu_options_undock.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					Component comp = tabs.getSelectedComponent();
					undockTab(comp);
				}
			});
			menu_options.add(menu_options_undock);

			menu_logging = new JMenu("Logging");
			menu.add(menu_logging);

			menu_logging_setLevel = new JMenuItem("set log level");
			menu_logging_setLevel.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					LogLevelFrame frame = LogLevelFrame.getInstance();
					if (frame == null)
						return;

					frame.setTitle("Set log level");
					frame.setVisible(true);
				}
			});
			menu_logging.add(menu_logging_setLevel);

		}

		{
			consolePanel = new JPanel(new BorderLayout(5, 5));

			output = new ThreadConsoleEditorPane(capacity);

			consolePanel.add(output, BorderLayout.CENTER);

			input = new JTextField();
			input.addActionListener(new ActionListener() {
				@Override
				public void actionPerformed(ActionEvent e) {
					synchronized (commands) {
						String cmd = input.getText();
						commands.add(cmd);
						input.setText("");
						commands.notifyAll();

						if (localEcho)
							output.addOutput(null, cmd + "\n", false);
					}
				}
			});
			consolePanel.add(input, BorderLayout.SOUTH);
		}

		add(tabs = new DnDTabbedPane());
		// add mouse listener for double-clicks on the tabs
		tabs.addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked(MouseEvent e) {
				if (e.getClickCount() == 2) {
					if (tabs.findComponentAt(e.getPoint()) == tabs) {
						int index = tabs.indexAtLocation(e.getX(), e.getY());
						if (index >= 0)
							undockTab(tabs.getComponentAt(index));
					}
				}
			}
		});

		addTab("Console", consolePanel, false);
		for (Entry<String, JPanel> entry : panels.entrySet())
			addTab(entry.getKey(), entry.getValue(), true);

		setSize(800, 600);
	}

	public void addTab(String title, Component tab, boolean scrollPane) {
		Component component;
		if (scrollPane) {
			component = new JScrollPane(tab);
			// set preferred dimension for undocking
			component.setPreferredSize(new Dimension(800, 600));
		} else
			component = tab;

		tabs.addTab(title, component);
		tab_titles.put(component, title);
	}

	@SuppressWarnings("serial")
	private void undockTab(final Component tab) {
		tabs.remove(tab);
		final String title = tab_titles.get(tab);

		new JFrame(title) {
			{
				setLayout(new BorderLayout());
				add(tab, BorderLayout.CENTER);
				setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
				addWindowListener(new WindowAdapter() {
					@Override
					public void windowClosing(WindowEvent e) {
						addTab(title, tab, false);
						dispose();
					}
				});
				pack();
				setSize(new Dimension(800, 600));
			}
		}.setVisible(true);
	}

	protected synchronized void write(final Thread thread, final String s,
			final boolean stdErr) {
		if (logWriter != null)
			try {
				logWriter.append(s);
				if (s.indexOf('\n') >= 0)
					logWriter.flush();
			} catch (IOException e) {
				logWriter = null;
				LOGGER.error("Error writing to log file", e);
			}

		output.addOutput(thread, s, stdErr);
	}

	private int getNextInput() {
		synchronized (commands) {
			while (commands.size() == 0)
				try {
					commands.wait();
				} catch (InterruptedException _) {
					// do nothing
				}
			String command = commands.getFirst();

			if (command.length() == 0) {
				commands.removeFirst();
				return '\n';
			}

			int result = command.charAt(0);
			command = command.substring(1);
			commands.set(0, command);

			return result;
		}
	}

	private int getAvailableInput() {
		int result = 0;
		synchronized (commands) {
			for (String command : commands)
				result += command.length();
		}
		return result;
	}

	/**
	 * @return the {@link JMenuBar} of the ConsoleWrapper's menu. Use this to
	 *         modify the menu at your will.
	 */
	public JMenuBar getMenu() {
		return menu;
	}

}
